# encoding=utf-8
# 说明:
#   使用PyWebIO库实现的简易站点，主要用于静态文档
#   支持Markdown/代码/图片/音视频等文件的在线浏览及在线修改；
#
# 注意:
#   .mp4/.mp3音视频实际上是转换为html语句, 因此为了兼容大多数浏览器，
#   .mp4不可为H.265编码, 需要H.264
#   word/pptx文档通过unoconv转换成pdf文档再展示
#
# 部署环境:
#   Linux
#   sudo apt install libreoffice
#
# 作者:
#   Bingo
#
import os
import glob
import json
import time
import fitz
import hashlib
from PIL import Image
import pandas as pd
from openpyxl import load_workbook
import uvicorn
from fastapi import FastAPI, Response
from fastapi.staticfiles import StaticFiles
import pywebio
from pywebio.platform.fastapi import webio_routes
from pywebio.output import (
    clear,
    put_button,
    put_text,
    put_markdown,
    put_buttons,
    put_collapse,
    put_row,
    put_scope,
    put_image,
    put_file,
    put_html,
    popup,
    close_popup,
    put_loading,
    put_tabs,
    put_table
)
from pywebio_battery import put_video, put_audio


class Const:
    web_title = 'Bingo 个人站点'
    server_ip = '192.168.0.100'
    server_port = 8888
    content_dir = 'content'
    home_file = 'home.'
    ignore_dir = []
    ignore_file = ['.__lock__', '.__cache__']
    code_theme = 'monokai'  # monokailight   # monokai
    counter = 'counter.json'
    customer_conf = 'customers.json'
    file_md5 = 'file_md5.json'
    git_push_switch = False
    gerrit_user = 'xxxx'
    commit_template = "Description:%s\nOwner:%s"
    read_me_file = 'ReadMe.md'
    merge_lock = 'merge.lock'


class Merge:

    @classmethod
    def git_add_commit(cls, file, commit_info):
        if os.system(f"git add {file}"):
            return None
        if os.system('git commit -m "%s"' % commit_info.replace('\n', ';')):
            return None
        cmd = "git push origin HEAD:refs/for/$(git branch|sed 's/* //g') 2>&1"
        for line in os.popen(cmd).readlines():
            if line.startswith('remote:') and 'https://' in line and '9443/' in line:
                return line.lstrip('remote:').strip().split(' ')[0]
        return None

    @classmethod
    def gerrit_merge(cls, url):
        param = '--code-review +2 --verified +1 --submit'
        server = url.split('//')[1].split(':')[0]
        cid = url.split('/')[-1]
        patch_set = 1
        cmd = f'ssh -p 29418 {Const.gerrit_user}@{server} gerrit review -m "--[Auto]" {cid},{patch_set} {param}'
        return not os.system(cmd)

    @classmethod
    def commit(cls, file, commit_info):
        if not Const.git_push_switch:    # 不进行git推送，直接返回成功
            return True
        # wait lock
        for _ in range(60):
            if os.path.isfile(Const.merge_lock):
                time.sleep(1)
            else:
                break
        File.write(Const.merge_lock, Session.get_user_id())
        try:
            if (url := cls.git_add_commit(file, commit_info)) is None:
                return False
            ret = cls.gerrit_merge(url)
        except:
            ret = False
        finally:
            os.remove(Const.merge_lock)
        return ret


class Session:

    @staticmethod
    def set_user_id(uid):
        pywebio.session.local.uid = uid

    @staticmethod
    def get_user_id():
        return pywebio.session.local.uid

    @staticmethod
    def set_curr_page(page):
        pywebio.session.local.curr_page = page

    @staticmethod
    def get_curr_page():
        return pywebio.session.local.curr_page

    @staticmethod
    def set_pdf_page_num(num):
        pywebio.session.local.pdf_page_num = num

    @staticmethod
    def get_pdf_page_num():
        return pywebio.session.local.pdf_page_num


class File:

    @classmethod
    def init(cls):
        if not os.path.isfile(Const.counter):
            with open(Const.counter, 'w+', encoding='utf-8') as f:
                f.write('{}')

    @classmethod
    def read(cls, file, mode='r'):
        if mode == 'r':
            with open(file, mode, encoding='utf-8') as f:
                return f.read()
        else:
            with open(file, mode) as f:
                return f.read()

    @classmethod
    def write(cls, file, data):
        with open(file, 'w+', encoding='utf-8') as f:
            f.write(data)

    @classmethod
    def count(cls, file):
        if cls.is_home_file(file):
            return
        with open(Const.counter, 'r', encoding='utf-8') as f:
            data = json.load(f)
        data[file] = data.get(file, 0) + 1
        with open(Const.counter, 'w+', encoding='utf-8') as f:
            f.write(json.dumps(data, indent=4, ensure_ascii=False))

    @classmethod
    def lock(cls):
        if os.path.isfile(lock_file := f'{Session.get_curr_page()}.__lock__'):
            with open(lock_file, 'r') as f:
                if (uid := f.read()) != Session.get_user_id():
                    return uid

                cls.lock_file = lock_file
                return None
        with open(lock_file, 'w+') as f:
            f.write(Session.get_user_id())

        cls.lock_file = lock_file
        return None

    @classmethod
    def unlock(cls):
        if os.path.isfile(lock_file := f'{Session.get_curr_page()}.__lock__'):
            os.remove(lock_file)

    @classmethod
    def mutex(cls):
        if os.path.isfile(lock_file := f'{Session.get_curr_page()}.__lock__'):
            try:
                if Session.get_user_id() == File.read(lock_file):
                    os.remove(lock_file)
            except:
                pass

    @classmethod
    def lstrip_content(cls, file):
        return file.lstrip(Const.content_dir + '/')

    @classmethod
    def is_home_file(cls, file):
        return file.startswith(os.path.join(Const.content_dir, Const.home_file))

    @classmethod
    def check_md5(cls, file, remove=False):
        if not os.path.isfile(Const.file_md5):
            cls.write(Const.file_md5, '{}')
        with open(Const.file_md5, 'r', encoding='utf-8') as f:
            md5_data = json.load(f)
        if not remove:
            with open(file, 'rb') as f:
                file_data = f.read()
            file_md5 = hashlib.md5(file_data).hexdigest()
            if file in md5_data:
                if file_md5 == md5_data[file]:
                    return True
            md5_data[file] = file_md5
        else:
            if file in md5_data:
                del md5_data[file]
        with open(Const.file_md5, 'w+', encoding='utf-8') as f:
            f.write(json.dumps(md5_data, indent=4, ensure_ascii=False))
        return False

    @classmethod
    def convert_pdf(cls, file):
        # sudo apt install unoconv
        # os.system(f'unoconv -f pdf {file} {os.path.dirname(file)}')
        os.system(f'soffice --convert-to pdf --outdir {os.path.dirname(file)} {file}')
        time.sleep(0.2)

    @classmethod
    def is_md(cls, file):
        return file.endswith('.md')

    @classmethod
    def is_html(cls, file):
        return file.endswith('.html')

    @classmethod
    def is_pdf(cls, file):
        return file.endswith('.pdf')

    @classmethod
    def is_python(cls, file):
        return file.endswith('.py')

    @classmethod
    def is_cpp(cls, file):
        return file.endswith(('.h', '.hxx', 'hpp', '.c', '.cxx', '.cpp'))

    @classmethod
    def is_java(cls, file):
        return file.endswith('.java')

    @classmethod
    def is_audio(cls, file):
        return file.endswith('.mp3')

    @classmethod
    def is_video(cls, file):
        return file.endswith('.mp4')

    @classmethod
    def is_image(cls, file):
        return file.endswith(('.png', '.jpg', '.jpeg'))

    @classmethod
    def is_readable_txt(cls, file):
        return file.endswith(('.txt', '.ini', '.json'))

    @classmethod
    def is_word(cls, file):
        return file.endswith(('doc', 'docx'))

    @classmethod
    def is_ppt(cls, file):
        return file.endswith('.pptx')

    @classmethod
    def is_excel(cls, file):
        return file.endswith(('.xlsx', '.xls'))

    @classmethod
    def is_csv(cls, file):
        return file.endswith('.csv')


class PathWalker(object):
    def __init__(self, path):
        self.path = path
        self.subs = []
        self.walk(path)

    def walk(self, path):
        if path == Const.content_dir:
            paths = sorted(os.listdir(path))[::-1]
        else:
            paths = sorted(os.listdir(path))
        for file in paths:
            sub = os.path.join(path, file)
            if os.path.isdir(sub) and file not in Const.ignore_dir:
                self.subs.append(PathWalker(sub))

    def output(self):
        out = []
        for sub in self.subs:
            out.append(sub.output())

        files = [file for file in os.listdir(self.path) if os.path.isfile(os.path.join(self.path, file)) and
                 not file.endswith(tuple(Const.ignore_file)) and
                 not (self.path == Const.content_dir and file.startswith(Const.home_file))]
        files.sort()
        for file in files:
            out.append(put_button(file, color='secondary', link_style=True, small=True,
                                  onclick=lambda x=os.path.join(self.path, file): Web.update(x)).style('height:26px'))
        if self.path == Const.content_dir:  # 最顶层不括起来
            return
        return put_collapse(os.path.split(self.path)[-1], out, open=False, scope='sidebar')


class Web:

    @classmethod
    def error(cls, info, timeout=0):
        popup(f"⛔ 错误", put_markdown(f'#### {info}'))
        if timeout:
            time.sleep(timeout)
            close_popup()

    @classmethod
    def info(cls, info, timeout=0):
        popup(f"ℹ️ 提示", put_markdown(f'#### {info}'))
        if timeout:
            time.sleep(timeout)
            close_popup()

    @classmethod
    def read_file(cls, file_name, is_bin=False):
        if not os.path.isfile(file_name):
            cls.error(f"`{file_name}` 已不存在，请手动F5刷新网页")
            return None
        try:
            data = File.read(file_name, 'rb') if is_bin else File.read(file_name)
        except Exception as e:
            cls.error(f"`{str(e)}`")
            return None
        return data

    @classmethod
    def put_text(cls, file_name):
        clear('body')
        if (data := cls.read_file(file_name)) is not None:
            put_text(data, scope='body')

    @classmethod
    def put_code(cls, file_name, code_type):
        if (data := cls.read_file(file_name)) is not None:
            pywebio.pin.put_textarea('', code={'mode': code_type, 'theme': Const.code_theme},
                                     value=data, rows=len(data.split('\n')), readonly=True, scope='body')

    @classmethod
    def put_pdf(cls, file_name, menubar_name='', width='1000px'):
        def up():
            Session.set_pdf_page_num(max(0, Session.get_pdf_page_num() - 1))
            show()

        def down():
            Session.set_pdf_page_num(Session.get_pdf_page_num() + 1)
            show()

        def show():
            clear('body')
            cls.menubar(menubar_name or file_name)
            try:
                page = doc.load_page(Session.get_pdf_page_num())
            except:
                Session.set_pdf_page_num(Session.get_pdf_page_num() - 1)
                page = doc.load_page(Session.get_pdf_page_num())
            pix = page.get_pixmap().pil_tobytes(format='png')
            put_image(pix, width=width, scope='body')
            put_text('', scope='body')
            put_row([
                None,
                put_button('上一页', color='secondary', onclick=up),
                None,
                put_button('下一页', color='secondary', onclick=down),
            ], size='10px 10% 60px 10%', scope='body').show()

        Session.set_pdf_page_num(0)
        doc = fitz.open(file_name)
        show()

    @classmethod
    def convert_pdf(cls, file_name, width='1200px'):
        trans_path = os.path.splitext(file_name)[0] + '.pdf'
        with put_loading(scope='body'):
            # md5一致，则不重新转换
            if not File.check_md5(file_name):
                File.convert_pdf(file_name)
                if os.path.isfile(trans_path):
                    os.rename(trans_path, f'{trans_path}.__cache__')
                else:
                    File.check_md5(file_name, remove=True)
                    cls.error('内部错误: 转换命令执行失败')
        cls.put_pdf(f'{trans_path}.__cache__', menubar_name=file_name, width=width)

    @classmethod
    def put_excel(cls, file_name, width='1200px'):
        try:
            tabs_list = []
            with put_loading(scope='body'):
                for sheet_name in load_workbook(file_name).sheetnames:
                    df = pd.read_excel(file_name, sheet_name=sheet_name, header=None, engine='openpyxl')
                    tabs_list.append({'title': sheet_name, 'content': put_table(df.fillna('').to_numpy().tolist())})
            put_tabs(tabs_list, scope='body').style(f'width:{width}')
        except Exception as e:
            cls.error(f"读取Excel失败 {file_name}: {str(e)}")

    @classmethod
    def pack(cls):
        clear('login')
        put_row([None, put_scope('sidebar'), None, put_scope('body'), None],
                size='50px 20% 50px 75% 50px').show()

    @classmethod
    def home(cls):
        cls.sidebar()

        files = glob.glob("%s*" % os.path.join(Const.content_dir, Const.home_file))
        files = [file for file in files if not file.endswith(tuple(Const.ignore_file))]
        if not files:
            cls.error("缺少`home.*`主页文件，请核查")
            return
        cls.update(files[0])
        with open(Const.counter, 'r', encoding='utf-8') as f:
            data = json.load(f)
        top_lines = '\n'.join(["{:.<12}《{}》".format(v, File.lstrip_content(k))
                               for k, v in sorted(data.items(), key=lambda x: x[1], reverse=True)][:5])
        put_markdown(f"""\n### 🔥 热门访问\n{top_lines}""", scope='body')

    @classmethod
    def login(cls):
        def check_form():
            username = pywebio.pin.pin['username']
            password = pywebio.pin.pin['password']
            if not username and not password:
                cls.error('用户名和密码输入为空')
                return
            with open(Const.customer_conf, 'r', encoding='utf-8') as f:
                user_info = json.load(f)
            if username not in user_info:
                cls.error('无该用户名信息，请联系管理员添加')
                return
            encode = hashlib.md5()
            encode.update(password.encode('utf8'))
            password = encode.hexdigest()
            if password != user_info.get(username):
                cls.error('密码错误, 请重新输入')
                return
            cls.info('登录成功', 1)
            Session.set_user_id(username)
            Session.set_curr_page('')
            Session.set_pdf_page_num(0)
            cls.pack()
            cls.home()

        def register():
            cls.info("请联系管理员添加")

        pywebio.session.set_env(title=Const.web_title, output_max_width='1800px')
        put_row([None, put_scope('login'), None], size='38% 400px 30%').show()
        put_text('\n' * 6, scope='login')
        put_image(Image.open('static/title.png'), scope='login')
        put_text('\n' * 2, scope='login')
        pywebio.pin.put_input(name='username', placeholder='用户名', scope='login')
        pywebio.pin.put_input(name='password', placeholder='密码', type=pywebio.input.PASSWORD, scope='login')
        put_row([
            put_button('登录', onclick=check_form, scope='login'),
            None,
            put_button('注册', color='warning', outline=True, onclick=register, scope='login'),
        ], size='20% 260px 20%', scope='login').show()

    @classmethod
    def menubar(cls, file):
        path = File.lstrip_content(file).replace('/', ' > ')
        if File.is_home_file(file):
            path = ''
        put_row([
            put_text(path),
            put_markdown('**`欢迎 %s`**' % Session.get_user_id()),
            put_button('✏️', onclick=cls.modify, color='dark', small=True, outline=True),
            put_button('⬇︎', onclick=cls.download, color='dark', small=True, outline=True),
            put_button('⬆︎︎', onclick=cls.upload, color='dark', small=True, outline=True),
            put_button('➕', onclick=cls.create, color='dark', small=True, outline=True),
            put_button('➖︎', onclick=cls.delete, color='dark', small=True, outline=True),
        ], size="60% 15% 4% 4% 4% 4% 4%", scope='body').show()

    @classmethod
    def sidebar(cls):
        clear('sidebar')
        put_image(Image.open('static/title.png'), width='300px', scope='sidebar')
        put_text(' ', scope='sidebar')
        put_row([
            pywebio.pin.put_input('search', placeholder='🔍 Search...'),
            put_button('Search', onclick=cls.search, color='dark'),
        ], size="270px 40px", scope='sidebar').show()
        put_text(' ', scope='sidebar')
        put_button('🏠 主页', onclick=cls.home, outline=True, color='dark', scope='sidebar')
        PathWalker.root = 'content'
        PathWalker('content').output()

    @classmethod
    def search(cls):
        clear('body')
        text = pywebio.pin.pin['search'].replace('"', '').replace("'''", '')
        if not text:
            cls.error("请输入搜索关键字!")
            return

        put_markdown('''- ### 文件名称匹配:''', scope='body')
        with put_loading(scope='body'):
            for root, _, files in os.walk(Const.content_dir):
                for file in files:
                    if text in file:
                        label = File.lstrip_content(os.path.join(root, file))
                        put_buttons([
                            dict(label=label, value=os.path.join(root, file), color='secondary', outline=True)
                        ], onclick=cls.update, scope='body')

        put_text(' ', scope='body')
        put_markdown('''- ### 文件内容匹配:''', scope='body')
        with put_loading(scope='body'):
            cmd = f'''grep -r "{text}" {Const.content_dir}/* | grep -v grep'''
            result = os.popen(cmd).read().strip()
            if result:
                last_name = ''
                for item in result.split('\n'):
                    items = item.split(':')
                    if len(items) < 2:
                        continue
                    if items[0] != last_name:
                        put_text(' ', scope='body')  # 加空行更美观
                        label = File.lstrip_content(items[0])
                        put_buttons([
                            dict(label=label, value=items[0], color='secondary', outline=True)
                        ], onclick=cls.update, scope='body')
                        last_name = items[0]
                    value = ':'.join(items[1:]).replace('\n', ' ')
                    if value:
                        put_text(value, scope='body')
            # cls.info("搜索结束", 1)

    @classmethod
    def modify(cls):
        def mod_contect():
            close_popup()
            cls.edit(False)

        def merge(_file):
            if Merge.commit(f'{_file} {page}', Const.commit_template % (f'rename to {_file}', Session.get_user_id())):
                cls.info("修改成功", 1)
                cls.sidebar()
                cls.update(_file)
            else:
                cls.error("提交失败，请联系管理员")
                os.rename(_file, page)
                cls.update(page)

        def onclick(choo):
            close_popup()
            if choo == '取消':
                return
            file_name = pywebio.pin.pin['file_name']
            if not file_name.strip():
                cls.error('输入的文件名不可为空')
                return
            for c in ('/', '#', '%', '&', '*', '(', ')', '|', '!', '@', '^'):
                if c in file_name:
                    cls.error("请输入正确的文件名 !\n"
                              "不可包含 '/', '#', '%', '&', '*', '(', ')', '|', '!', '@', '^' 等特殊符号")
                    return
            new_file = os.path.join(os.path.dirname(page), file_name)
            os.rename(page, new_file)
            merge(new_file)

        def mod_name():
            close_popup()
            popup("修改文件名", [
                put_markdown(f"##### 当前文件名：`{os.path.basename(page)}`"),
                pywebio.pin.put_input("file_name", placeholder='请输入新的文件名'),
                put_buttons([dict(label=which, value=which, color='success') for which in ('确认', '取消')],
                            small=True, onclick=onclick)
            ])

        page = Session.get_curr_page()
        popup("修改", [
            put_markdown(f"##### 文件路径：`{File.lstrip_content(page)}`"),
            put_row([
                None, put_button('修改内容', onclick=mod_contect, small=True, color='dark', outline=True),
                None, put_button('修改文件名', onclick=mod_name, small=True, color='dark', outline=True)
            ], size="50px 200px 30px 100px")
        ])

    @classmethod
    def download(cls):
        page = Session.get_curr_page()
        popup("下载", [
            put_markdown(f"##### 文件路径：`{File.lstrip_content(page)}`"),
            pywebio.pin.put_select('download_type', options=['原格式']),  # , 'pdf', 'html'])
            put_file(os.path.basename(page), open(page, 'rb').read(), '开始下载'),
        ])

    @classmethod
    def upload(cls):
        def merge(_file):
            if Merge.commit(_file, Const.commit_template % (f'upload file {page}', Session.get_user_id())):
                cls.info("上传成功", 1)
                cls.sidebar()
                cls.update(_file)
            else:
                os.remove(_file)
                cls.error("提交失败，请联系管理员")
                cls.home()

        def onclick(choo):
            close_popup()
            if choo == "取消":
                return
            if file_info := pywebio.pin.pin['upload_file']:
                file_path = os.path.join(os.path.dirname(Session.get_curr_page()), file_info['filename'])
                if '文件存在时覆盖' not in pywebio.pin.pin['cover_file'] and os.path.isfile(file_path):
                    cls.error("文件已经存在，如仍需上传，请勾选覆盖")
                    return
                with open(file_path, 'wb') as f:
                    f.write(file_info['content'])
                merge(file_path)

        page = Session.get_curr_page()
        if File.is_home_file(page):
            cls.error("主页无法上传文件，请切换到其他文档")
            return
        popup("上传", [
            put_markdown(f"##### 文件上传后的路径与当前文档`{page}`一致"),
            pywebio.pin.put_file_upload("upload_file", placeholder=''),
            pywebio.pin.put_checkbox("cover_file", options=["文件存在时覆盖"]),
            put_buttons([dict(label=which, value=which, color='success') for which in ('确认', '取消')],
                        small=True, onclick=onclick)
        ])

    @classmethod
    def create(cls):
        def merge(_file):
            if Merge.commit(_file, Const.commit_template % (f"create dir & file {_file}", Session.get_user_id())):
                cls.info(f"提交成功", 1)
            else:
                cls.error('提交失败，请联系管理员')

        def onclick(choo):
            close_popup()
            if choo == '取消':
                return
            file_name = pywebio.pin.pin['file_name']
            if not file_name.strip():
                cls.error('输入的文件名或目录名不可为空')
                return
            file_path = os.path.join(os.path.dirname(page), file_name)
            if pywebio.pin.pin['create_file'] == "新建文件":
                if File.is_home_file(page):
                    cls.error("主页不能新建文件，只能新建目录")
                    return
                Session.set_curr_page(file_path)
                cls.edit(True)
            else:
                if os.path.isdir(file_path):
                    cls.error("文件夹已存在")
                    return
                os.makedirs(file_path)
                default_file = os.path.join(file_path, Const.read_me_file)
                File.write(default_file, f'### 目录名\n{file_path}\n\n### 说明\nxxx')
                merge('%s %s' % (file_path, default_file))
                cls.sidebar()
                cls.update(default_file)

        page = Session.get_curr_page()
        popup('新建', [
            put_markdown(f"##### 新建文档的路径与当前文档`{page}`一致\n"
                         f"##### 新建目录的所在路径为`{os.path.dirname(page)}`"),
            pywebio.pin.put_select("create_file", options=['新建文件', '新建目录']),
            pywebio.pin.put_input("file_name", placeholder='文件名或目录名'),
            put_buttons([dict(label=which, value=which, color='success') for which in ('确认', '取消')],
                        small=True, onclick=onclick)
        ])

    @classmethod
    def delete(cls):
        def merge(_file):
            if Merge.commit(_file, Const.commit_template % (f"delete file {_file}", Session.get_user_id())):
                cls.info(f"提交成功", 1)
            else:
                cls.error('提交失败，请联系管理员')
            cls.home()

        def onclick(choo):
            close_popup()
            if choo == "取消":
                return
            if os.path.isfile(cache := os.path.splitext(page)[0] + '.pdf.__cache__'):
                File.check_md5(page, remove=True)
                os.remove(cache)
            os.remove(page)
            curr_dir = os.path.dirname(page)
            file = page
            if not os.listdir(curr_dir):         # 删除的是当前目录下的最后一个文件，因此本目录也一并删除
                os.rmdir(curr_dir)
                file = '%s %s' % (file, curr_dir)
            merge(file)

        page = Session.get_curr_page()
        if File.is_home_file(page):
            cls.error("主页文件不能删除")
            return
        if not os.path.isfile(page):
            cls.error(f"##### `{page}`\n当前文件不存在，可能已经被其他用户删除")
            return
        popup("删除", [
            put_markdown(f"##### 确定删除`{page}`?"),
            put_buttons([dict(label=which, value=which, color='success') for which in ('确认', '取消')],
                        small=True, onclick=onclick)
        ])

    @classmethod
    def edit(cls, is_new=False):
        def get_code():
            code = {}
            if File.is_python(page):
                code = {'mode': "python", 'theme': Const.code_theme}
            elif File.is_cpp(page):
                code = {'mode': "C++", 'theme': Const.code_theme}
            elif File.is_readable_txt(page):
                code = {'mode': page.split('.')[-1], 'theme': Const.code_theme}
            return code

        def merge():
            if not (commit_info := pywebio.pin.pin['commit']):
                cls.error('未填写commit信息')
                return
            File.write(page, pywebio.pin.pin['edit'])
            if Merge.commit(page, commit_info):
                cls.info(f"提交成功", 1)
                if is_new:
                    cls.sidebar()
                cls.update(page)
            else:
                os.remove(page) if is_new else File.write(page, data_before)
                cls.error('提交失败，请联系管理员')
                # TODO put_text data_edit

        def onclick(choo):
            File.unlock()
            if choo == '取消':
                cls.home() if is_new else cls.update(page)
                return

            if (data_edit := pywebio.pin.pin['edit']) == data_before:
                cls.info("未修改内容", 1)
                return
            if not Const.git_push_switch:
                File.write(page, data_edit)
                if is_new:
                    cls.sidebar()
                cls.update(page)
                return
            popup(
                'Commit Info',
                [pywebio.pin.put_textarea('commit', value=Const.commit_template % ('', Session.get_user_id())),
                 put_button('提交', onclick=merge)]
            )

        page = Session.get_curr_page()
        if (uid := File.lock()) is not None:
            cls.error(f"当前文档正在被 {uid} 修改")
            return
        clear('body')
        data_before = None if is_new else cls.read_file(page)
        put_text(File.lstrip_content(page).replace('/', ' > '), scope='body')
        pywebio.pin.put_textarea('edit', rows=42, code=get_code(), value=data_before, scope='body')
        put_buttons([dict(label=which, value=which, color='success') for which in ('确认', '取消')],
                    small=True, onclick=onclick, scope='body')

    @classmethod
    def update(cls, file_name):
        File.mutex()
        File.count(file_name)
        clear('body')
        cls.menubar(file_name)
        if File.is_md(file_name):
            if (data := cls.read_file(file_name)) is not None:
                put_markdown(data, scope='body').style('width:1300px')
        elif File.is_python(file_name):
            cls.put_code(file_name, 'python')
        elif File.is_cpp(file_name):
            cls.put_code(file_name, 'C++')
        elif File.is_java(file_name):
            cls.put_code(file_name, 'java')
        elif File.is_audio(file_name):
            paths = file_name.split('/')
            put_audio(f'/static/attach/{paths[-2]}/{paths[-1]}', scope='body')
        elif File.is_video(file_name):
            paths = file_name.split('/')
            put_video(f'/static/attach/{paths[-2]}/{paths[-1]}', width='1300px', scope='body')
        elif File.is_image(file_name):
            if (data := cls.read_file(file_name, True)) is not None:
                put_image(data, scope='body')
        elif File.is_html(file_name):
            if (data := cls.read_file(file_name)) is not None:
                put_html(data, scope='body')
        elif File.is_pdf(file_name):
            cls.put_pdf(file_name, width='800px')
        elif File.is_word(file_name):
            cls.convert_pdf(file_name, width='1000px')
        elif File.is_ppt(file_name):
            cls.convert_pdf(file_name, width='1300px')
        elif File.is_csv(file_name):
            put_table(pd.read_csv(file_name, encoding='utf-8').fillna('').to_numpy().tolist(),
                      scope='body').style('width:1300px')
        elif File.is_excel(file_name):
            cls.put_excel(file_name, width='1300px')
        elif File.is_readable_txt(file_name):
            cls.put_code(file_name, file_name.split('.')[-1])
        else:
            # '.zip', '.gz', '.rar', ...
            if (data := cls.read_file(file_name, True)) is not None:
                put_markdown("> #### 不支持预览的文件类型, 可点击下载", scope='body')
                put_file(os.path.split(file_name)[1], data, label=File.lstrip_content(file_name), scope='body')
                put_buttons([
                    dict(label='以文本形式显示', value=file_name, color='danger')
                ], onclick=cls.put_text, scope='body')
        Session.set_curr_page(file_name)


def main():
    File.init()
    Web.login()


app = FastAPI(docs_url=None)


@app.get("/static/{file_type}/{path}/{name}")
async def get_file(file_type, path, name):
    file_path = os.path.join('static', file_type, path, name)
    image_data = File.read(file_path, 'rb')
    return Response(content=image_data)  # , media_type=f"image/{name.split('.')[-1]}")


@app.get("/__cdn__/{path}/{file}")
async def get_cdn(path, file):
    mode = 'rb' if file.endswith('.png') else 'r'
    return Response(content=open(os.path.join(pywebio.STATIC_PATH, path, file), mode).read())


@app.get("/__cdn__/{path}/{sub}/{file}")
async def get_cdn(path, sub, file):
    mode = 'rb' if file.endswith('.png') else 'r'
    return Response(content=open(os.path.join(pywebio.STATIC_PATH, path, sub, file), mode).read())


app.mount("/", FastAPI(routes=webio_routes(main, cdn='__cdn__')))
app.mount("/static", StaticFiles(directory="static"), name="static")


if __name__ == '__main__':
    uvicorn.run(app="DocsWeb:app", host=Const.server_ip, port=Const.server_port, reload=True)

